refactor(agent): tighten harness session storage
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import type { CodingAgentSessionInfo, SessionInfo, SessionTreeEntry, SessionTreeStorage } from "../types.js";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { JsonlSessionInfo, SessionTreeEntry, SessionTreeStorage } from "../types.js";
|
||||
|
||||
interface SessionHeader {
|
||||
type: "session";
|
||||
@@ -12,116 +13,140 @@ interface SessionHeader {
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
function headerToSessionInfo(header: SessionHeader, filePath?: string): CodingAgentSessionInfo {
|
||||
function headerToSessionInfo(header: SessionHeader, path: string): JsonlSessionInfo {
|
||||
return {
|
||||
id: header.id,
|
||||
createdAt: header.timestamp,
|
||||
parentSession: header.parentSession,
|
||||
projectCwd: header.cwd,
|
||||
filePath,
|
||||
cwd: header.cwd,
|
||||
path,
|
||||
parentSessionPath: header.parentSession,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadJsonlStorage(
|
||||
filePath: string,
|
||||
): Promise<{ header?: SessionHeader; entries: SessionTreeEntry[]; leafId: string | null }> {
|
||||
export async function loadJsonlSessionInfo(filePath: string): Promise<JsonlSessionInfo> {
|
||||
const stream = createReadStream(filePath, { encoding: "utf8" });
|
||||
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
try {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let header: SessionHeader | undefined;
|
||||
let leafId: string | null = null;
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) break;
|
||||
try {
|
||||
const record = JSON.parse(line) as SessionHeader | SessionTreeEntry;
|
||||
if (record.type === "session") {
|
||||
header = record as SessionHeader;
|
||||
continue;
|
||||
}
|
||||
entries.push(record as SessionTreeEntry);
|
||||
leafId = (record as SessionTreeEntry).id;
|
||||
const header = JSON.parse(line) as SessionHeader;
|
||||
return headerToSessionInfo(header, resolve(filePath));
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
|
||||
}
|
||||
}
|
||||
return { header, entries, leafId };
|
||||
} catch {
|
||||
return { entries: [], leafId: null };
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
} finally {
|
||||
lines.close();
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonlSessionTreeStorage implements SessionTreeStorage {
|
||||
private filePath: string;
|
||||
private cwd: string;
|
||||
private headerInitialized = false;
|
||||
private cacheLoaded = false;
|
||||
private sessionInfo?: CodingAgentSessionInfo;
|
||||
private entries: SessionTreeEntry[] = [];
|
||||
private byId = new Map<string, SessionTreeEntry>();
|
||||
private currentLeafId: string | null = null;
|
||||
private requestedSessionId?: string;
|
||||
private parentSession?: string;
|
||||
async function loadJsonlStorage(filePath: string): Promise<{
|
||||
header: SessionHeader;
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
}> {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const lines = content.split("\n").filter((line) => line.trim());
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
}
|
||||
|
||||
constructor(filePath: string, options: { cwd: string; sessionId?: string; parentSession?: string }) {
|
||||
let header: SessionHeader;
|
||||
try {
|
||||
header = JSON.parse(lines[0]!) as SessionHeader;
|
||||
} catch {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
|
||||
}
|
||||
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let leafId: string | null = null;
|
||||
for (const line of lines.slice(1)) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as SessionTreeEntry;
|
||||
entries.push(entry);
|
||||
leafId = entry.id;
|
||||
} catch {
|
||||
// ignore malformed entry lines
|
||||
}
|
||||
}
|
||||
return { header, entries, leafId };
|
||||
}
|
||||
|
||||
export class JsonlSessionTreeStorage implements SessionTreeStorage<JsonlSessionInfo> {
|
||||
private readonly filePath: string;
|
||||
private readonly header: SessionHeader;
|
||||
private readonly sessionInfo: JsonlSessionInfo;
|
||||
private entries: SessionTreeEntry[];
|
||||
private byId: Map<string, SessionTreeEntry>;
|
||||
private currentLeafId: string | null;
|
||||
private headerWritten: boolean;
|
||||
|
||||
private constructor(
|
||||
filePath: string,
|
||||
header: SessionHeader,
|
||||
entries: SessionTreeEntry[],
|
||||
leafId: string | null,
|
||||
headerWritten: boolean,
|
||||
) {
|
||||
this.filePath = resolve(filePath);
|
||||
this.cwd = options.cwd;
|
||||
this.requestedSessionId = options.sessionId;
|
||||
this.parentSession = options.parentSession;
|
||||
this.header = header;
|
||||
this.sessionInfo = headerToSessionInfo(header, this.filePath);
|
||||
this.entries = entries;
|
||||
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
this.currentLeafId = leafId;
|
||||
this.headerWritten = headerWritten;
|
||||
}
|
||||
|
||||
private async ensureParentDir(): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
static async open(filePath: string): Promise<JsonlSessionTreeStorage> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const loaded = await loadJsonlStorage(resolvedPath);
|
||||
return new JsonlSessionTreeStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId, true);
|
||||
}
|
||||
|
||||
private async ensureLoaded(): Promise<void> {
|
||||
if (this.cacheLoaded) {
|
||||
return;
|
||||
}
|
||||
const loaded = await loadJsonlStorage(this.filePath);
|
||||
this.entries = loaded.entries;
|
||||
this.byId = new Map(loaded.entries.map((entry) => [entry.id, entry]));
|
||||
this.currentLeafId = loaded.leafId;
|
||||
this.headerInitialized = loaded.header !== undefined;
|
||||
if (loaded.header) {
|
||||
this.sessionInfo = headerToSessionInfo(loaded.header, this.filePath);
|
||||
}
|
||||
this.cacheLoaded = true;
|
||||
}
|
||||
|
||||
private async ensureHeader(): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
if (this.headerInitialized) return;
|
||||
await this.ensureParentDir();
|
||||
static async create(
|
||||
filePath: string,
|
||||
options: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
parentSessionPath?: string;
|
||||
},
|
||||
): Promise<JsonlSessionTreeStorage> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: this.requestedSessionId ?? randomUUID(),
|
||||
id: options.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: this.cwd,
|
||||
parentSession: this.parentSession,
|
||||
cwd: options.cwd,
|
||||
parentSession: options.parentSessionPath,
|
||||
};
|
||||
await writeFile(this.filePath, `${JSON.stringify(header)}\n`);
|
||||
this.sessionInfo = headerToSessionInfo(header, this.filePath);
|
||||
this.headerInitialized = true;
|
||||
return new JsonlSessionTreeStorage(resolvedPath, header, [], null, false);
|
||||
}
|
||||
|
||||
async getSessionInfo(): Promise<SessionInfo> {
|
||||
await this.ensureHeader();
|
||||
return this.sessionInfo!;
|
||||
async getSessionInfo(): Promise<JsonlSessionInfo> {
|
||||
return this.sessionInfo;
|
||||
}
|
||||
|
||||
async getLeafId(): Promise<string | null> {
|
||||
await this.ensureLoaded();
|
||||
return this.currentLeafId;
|
||||
}
|
||||
|
||||
async setLeafId(leafId: string | null): Promise<void> {
|
||||
await this.ensureLoaded();
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new Error(`Entry ${leafId} not found`);
|
||||
}
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
await this.ensureHeader();
|
||||
if (!this.headerWritten) {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
await writeFile(this.filePath, `${JSON.stringify(this.header)}\n`);
|
||||
this.headerWritten = true;
|
||||
}
|
||||
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
@@ -129,12 +154,10 @@ export class JsonlSessionTreeStorage implements SessionTreeStorage {
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
await this.ensureLoaded();
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||
await this.ensureLoaded();
|
||||
if (leafId === null) return [];
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let current = this.byId.get(leafId);
|
||||
@@ -146,7 +169,6 @@ export class JsonlSessionTreeStorage implements SessionTreeStorage {
|
||||
}
|
||||
|
||||
async getEntries(): Promise<SessionTreeEntry[]> {
|
||||
await this.ensureLoaded();
|
||||
return [...this.entries];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { SessionInfo, SessionTreeEntry, SessionTreeStorage } from "../types.js";
|
||||
|
||||
export class InMemorySessionTreeStorage implements SessionTreeStorage {
|
||||
private readonly sessionInfo: SessionInfo;
|
||||
private entries: SessionTreeEntry[];
|
||||
private byId: Map<string, SessionTreeEntry>;
|
||||
private leafId: string | null;
|
||||
private sessionInfo: SessionInfo;
|
||||
|
||||
constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; sessionInfo?: SessionInfo }) {
|
||||
this.entries = options?.entries ? [...options.entries] : [];
|
||||
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
|
||||
this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null;
|
||||
this.sessionInfo = options?.sessionInfo ?? { id: randomUUID(), createdAt: new Date().toISOString() };
|
||||
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
||||
throw new Error(`Entry ${this.leafId} not found`);
|
||||
}
|
||||
this.sessionInfo = options?.sessionInfo ?? { id: uuidv7(), createdAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
async getSessionInfo(): Promise<SessionInfo> {
|
||||
@@ -21,26 +26,29 @@ export class InMemorySessionTreeStorage implements SessionTreeStorage {
|
||||
}
|
||||
|
||||
async setLeafId(leafId: string | null): Promise<void> {
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new Error(`Entry ${leafId} not found`);
|
||||
}
|
||||
this.leafId = leafId;
|
||||
}
|
||||
|
||||
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
this.leafId = entry.id;
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
return this.entries.find((entry) => entry.id === id);
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||
if (leafId === null) return [];
|
||||
const byId = new Map<string, SessionTreeEntry>(this.entries.map((entry) => [entry.id, entry]));
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let current = byId.get(leafId);
|
||||
let current = this.byId.get(leafId);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type {
|
||||
CodingAgentSessionInfo,
|
||||
CodingAgentSessionRepo,
|
||||
JsonlSessionInfo,
|
||||
JsonlSessionRepo,
|
||||
Session,
|
||||
SessionInfo,
|
||||
SessionRepo,
|
||||
SessionTreeEntry,
|
||||
SessionTreeStorage,
|
||||
} from "../types.js";
|
||||
import { JsonlSessionTreeStorage } from "./jsonl-session-storage.js";
|
||||
import { InMemorySessionTreeStorage } from "./memory-session-storage.js";
|
||||
@@ -21,8 +22,11 @@ function createTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function toSession<TInfo extends SessionInfo>(info: TInfo, tree: DefaultSessionTree): Session<TInfo> {
|
||||
return { info, tree };
|
||||
function toSession<TInfo extends SessionInfo>(
|
||||
storage: SessionTreeStorage<TInfo>,
|
||||
tree: DefaultSessionTree<TInfo>,
|
||||
): Session<TInfo> {
|
||||
return { storage, tree };
|
||||
}
|
||||
|
||||
function getPathEntriesToFork(
|
||||
@@ -59,14 +63,13 @@ function getPathEntriesToFork(
|
||||
export class InMemorySessionRepo implements SessionRepo<string> {
|
||||
private sessions = new Map<string, Session<SessionInfo>>();
|
||||
|
||||
async create(options?: { id?: string; parentSession?: string }): Promise<Session<SessionInfo>> {
|
||||
async create(options?: { id?: string }): Promise<Session<SessionInfo>> {
|
||||
const info: SessionInfo = {
|
||||
id: options?.id ?? createSessionId(),
|
||||
createdAt: createTimestamp(),
|
||||
parentSession: options?.parentSession,
|
||||
};
|
||||
const storage = new InMemorySessionTreeStorage({ sessionInfo: info });
|
||||
const session = toSession(info, new DefaultSessionTree(storage));
|
||||
const session = toSession(storage, new DefaultSessionTree(storage));
|
||||
this.sessions.set(info.id, session);
|
||||
return session;
|
||||
}
|
||||
@@ -97,42 +100,16 @@ export class InMemorySessionRepo implements SessionRepo<string> {
|
||||
const info: SessionInfo = {
|
||||
id: options.id ?? createSessionId(),
|
||||
createdAt: createTimestamp(),
|
||||
parentSession: source.info.id,
|
||||
};
|
||||
const leafId = forkedEntries[forkedEntries.length - 1]?.id ?? null;
|
||||
const storage = new InMemorySessionTreeStorage({ sessionInfo: info, entries: forkedEntries, leafId });
|
||||
const session = toSession(info, new DefaultSessionTree(storage));
|
||||
const session = toSession(storage, new DefaultSessionTree(storage));
|
||||
this.sessions.set(info.id, session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonlHeader(filePath: string): CodingAgentSessionInfo | undefined {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const firstLine = content.split("\n")[0];
|
||||
if (!firstLine) return undefined;
|
||||
const header = JSON.parse(firstLine) as {
|
||||
type: string;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
parentSession?: string;
|
||||
};
|
||||
if (header.type !== "session") return undefined;
|
||||
return {
|
||||
id: header.id,
|
||||
createdAt: header.timestamp,
|
||||
parentSession: header.parentSession,
|
||||
projectCwd: header.cwd,
|
||||
filePath,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<string> {
|
||||
export class JsonlSessionFileRepo implements JsonlSessionRepo<string> {
|
||||
private sessionDir: string;
|
||||
private cwd: string;
|
||||
|
||||
@@ -146,55 +123,66 @@ export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<strin
|
||||
return join(this.sessionDir, `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
async create(options?: { id?: string; parentSession?: string }): Promise<Session<CodingAgentSessionInfo>> {
|
||||
async create(options?: { id?: string; parentSessionPath?: string }): Promise<Session<JsonlSessionInfo>> {
|
||||
const id = options?.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const filePath = this.createSessionFilePath(id, createdAt);
|
||||
const storage = new JsonlSessionTreeStorage(filePath, {
|
||||
const storage = await JsonlSessionTreeStorage.create(filePath, {
|
||||
cwd: this.cwd,
|
||||
sessionId: id,
|
||||
parentSession: options?.parentSession,
|
||||
parentSessionPath: options?.parentSessionPath,
|
||||
});
|
||||
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
|
||||
return toSession(info, new DefaultSessionTree(storage));
|
||||
return toSession(storage, new DefaultSessionTree(storage));
|
||||
}
|
||||
|
||||
async open(ref: string): Promise<Session<CodingAgentSessionInfo>> {
|
||||
async open(ref: string): Promise<Session<JsonlSessionInfo>> {
|
||||
const filePath = ref.includes("/") || ref.endsWith(".jsonl") ? resolve(ref) : join(this.sessionDir, ref);
|
||||
if (!existsSync(filePath)) {
|
||||
throw new Error(`Session not found: ${ref}`);
|
||||
}
|
||||
const storage = new JsonlSessionTreeStorage(filePath, { cwd: this.cwd });
|
||||
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
|
||||
return toSession(info, new DefaultSessionTree(storage));
|
||||
const storage = await JsonlSessionTreeStorage.open(filePath);
|
||||
return toSession(storage, new DefaultSessionTree(storage));
|
||||
}
|
||||
|
||||
async list(): Promise<Array<Session<CodingAgentSessionInfo>>> {
|
||||
async list(): Promise<Array<Session<JsonlSessionInfo>>> {
|
||||
if (!existsSync(this.sessionDir)) {
|
||||
return [];
|
||||
}
|
||||
const files = readdirSync(this.sessionDir)
|
||||
.filter((file) => file.endsWith(".jsonl"))
|
||||
.map((file) => join(this.sessionDir, file));
|
||||
const sessions: Array<Session<CodingAgentSessionInfo>> = [];
|
||||
const sessions: Array<Session<JsonlSessionInfo>> = [];
|
||||
for (const filePath of files) {
|
||||
const info = readJsonlHeader(filePath);
|
||||
if (!info) continue;
|
||||
sessions.push(
|
||||
toSession(info, new DefaultSessionTree(new JsonlSessionTreeStorage(filePath, { cwd: info.projectCwd }))),
|
||||
);
|
||||
try {
|
||||
const storage = await JsonlSessionTreeStorage.open(filePath);
|
||||
sessions.push(toSession(storage, new DefaultSessionTree(storage)));
|
||||
} catch {
|
||||
// Ignore invalid session files when listing a directory.
|
||||
}
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async listByCwd(cwd: string): Promise<Array<Session<CodingAgentSessionInfo>>> {
|
||||
return (await this.list()).filter((session) => session.info.projectCwd === cwd);
|
||||
async listByCwd(cwd: string): Promise<Array<Session<JsonlSessionInfo>>> {
|
||||
const sessions = await this.list();
|
||||
const result: Array<Session<JsonlSessionInfo>> = [];
|
||||
for (const session of sessions) {
|
||||
if ((await session.storage.getSessionInfo()).cwd === cwd) {
|
||||
result.push(session);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getMostRecentByCwd(cwd: string): Promise<Session<CodingAgentSessionInfo> | undefined> {
|
||||
const sessions = await this.listByCwd(cwd);
|
||||
sessions.sort((a, b) => new Date(b.info.createdAt).getTime() - new Date(a.info.createdAt).getTime());
|
||||
return sessions[0];
|
||||
async getMostRecentByCwd(cwd: string): Promise<Session<JsonlSessionInfo> | undefined> {
|
||||
const sessionsWithInfo = await Promise.all(
|
||||
(await this.listByCwd(cwd)).map(async (session) => ({
|
||||
session,
|
||||
info: await session.storage.getSessionInfo(),
|
||||
})),
|
||||
);
|
||||
sessionsWithInfo.sort((a, b) => new Date(b.info.createdAt).getTime() - new Date(a.info.createdAt).getTime());
|
||||
return sessionsWithInfo[0]?.session;
|
||||
}
|
||||
|
||||
async delete(ref: string): Promise<void> {
|
||||
@@ -207,17 +195,18 @@ export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<strin
|
||||
async fork(
|
||||
ref: string,
|
||||
options: { entryId: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<CodingAgentSessionInfo>> {
|
||||
): Promise<Session<JsonlSessionInfo>> {
|
||||
const source = await this.open(ref);
|
||||
const entries = await source.tree.getEntries();
|
||||
const forkedEntries = getPathEntriesToFork(entries, options.entryId, options.position ?? "before");
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const filePath = this.createSessionFilePath(id, createdAt);
|
||||
const storage = new JsonlSessionTreeStorage(filePath, {
|
||||
cwd: source.info.projectCwd,
|
||||
const sourceInfo = await source.storage.getSessionInfo();
|
||||
const storage = await JsonlSessionTreeStorage.create(filePath, {
|
||||
cwd: sourceInfo.cwd,
|
||||
sessionId: id,
|
||||
parentSession: source.info.filePath ?? source.info.id,
|
||||
parentSessionPath: sourceInfo.path,
|
||||
});
|
||||
for (const entry of forkedEntries) {
|
||||
await storage.appendEntry(entry);
|
||||
@@ -225,7 +214,6 @@ export class JsonlCodingAgentSessionRepo implements CodingAgentSessionRepo<strin
|
||||
if (forkedEntries.length === 0) {
|
||||
await storage.getSessionInfo();
|
||||
}
|
||||
const info = (await storage.getSessionInfo()) as CodingAgentSessionInfo;
|
||||
return toSession(info, new DefaultSessionTree(storage));
|
||||
return toSession(storage, new DefaultSessionTree(storage));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
SessionTreeStorage,
|
||||
ThinkingLevelChangeEntry,
|
||||
} from "../types.js";
|
||||
import { InMemorySessionTreeStorage } from "./memory-session-storage.js";
|
||||
|
||||
function generateId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
@@ -79,11 +78,11 @@ export function buildSessionContext(entries: SessionTreeEntry[]): SessionContext
|
||||
return { messages, thinkingLevel, model };
|
||||
}
|
||||
|
||||
export class DefaultSessionTree implements SessionTree {
|
||||
private storage: SessionTreeStorage;
|
||||
export class DefaultSessionTree<TInfo extends SessionInfo = SessionInfo> implements SessionTree {
|
||||
private storage: SessionTreeStorage<TInfo>;
|
||||
|
||||
constructor(storage?: SessionTreeStorage) {
|
||||
this.storage = storage ?? new InMemorySessionTreeStorage();
|
||||
constructor(storage: SessionTreeStorage<TInfo>) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
getLeafId(): Promise<string | null> {
|
||||
@@ -107,7 +106,7 @@ export class DefaultSessionTree implements SessionTree {
|
||||
return buildSessionContext(await this.getBranch());
|
||||
}
|
||||
|
||||
getSessionInfo(): Promise<SessionInfo> {
|
||||
getSessionInfo(): Promise<TInfo> {
|
||||
return this.storage.getSessionInfo();
|
||||
}
|
||||
|
||||
|
||||
@@ -160,16 +160,16 @@ export interface SessionContext {
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
export interface CodingAgentSessionInfo extends SessionInfo {
|
||||
projectCwd: string;
|
||||
filePath?: string;
|
||||
export interface JsonlSessionInfo extends SessionInfo {
|
||||
cwd: string;
|
||||
path: string;
|
||||
parentSessionPath?: string;
|
||||
}
|
||||
|
||||
export interface SessionTreeStorage {
|
||||
getSessionInfo(): Promise<SessionInfo>;
|
||||
export interface SessionTreeStorage<TInfo extends SessionInfo = SessionInfo> {
|
||||
getSessionInfo(): Promise<TInfo>;
|
||||
getLeafId(): Promise<string | null>;
|
||||
setLeafId(leafId: string | null): Promise<void>;
|
||||
appendEntry(entry: SessionTreeEntry): Promise<void>;
|
||||
@@ -179,7 +179,6 @@ export interface SessionTreeStorage {
|
||||
}
|
||||
|
||||
export interface SessionTree {
|
||||
getSessionInfo(): Promise<SessionInfo>;
|
||||
getLeafId(): Promise<string | null>;
|
||||
getEntry(id: string): Promise<SessionTreeEntry | undefined>;
|
||||
getEntries(): Promise<SessionTreeEntry[]>;
|
||||
@@ -213,21 +212,40 @@ export interface SessionTree {
|
||||
}
|
||||
|
||||
export interface Session<TInfo extends SessionInfo = SessionInfo> {
|
||||
info: TInfo;
|
||||
storage: SessionTreeStorage<TInfo>;
|
||||
tree: SessionTree;
|
||||
}
|
||||
|
||||
export interface SessionRepo<TRef = string, TInfo extends SessionInfo = SessionInfo> {
|
||||
create(options?: { id?: string; parentSession?: string }): Promise<Session<TInfo>>;
|
||||
export interface SessionCreateOptions {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface SessionForkOptions {
|
||||
entryId: string;
|
||||
position?: "before" | "at";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface SessionRepo<
|
||||
TRef = string,
|
||||
TInfo extends SessionInfo = SessionInfo,
|
||||
TCreateOptions extends SessionCreateOptions = SessionCreateOptions,
|
||||
> {
|
||||
create(options?: TCreateOptions): Promise<Session<TInfo>>;
|
||||
open(ref: TRef): Promise<Session<TInfo>>;
|
||||
list(): Promise<Array<Session<TInfo>>>;
|
||||
delete(ref: TRef): Promise<void>;
|
||||
fork(ref: TRef, options: { entryId: string; position?: "before" | "at"; id?: string }): Promise<Session<TInfo>>;
|
||||
fork(ref: TRef, options: SessionForkOptions): Promise<Session<TInfo>>;
|
||||
}
|
||||
|
||||
export interface CodingAgentSessionRepo<TRef = string> extends SessionRepo<TRef, CodingAgentSessionInfo> {
|
||||
listByCwd(cwd: string): Promise<Array<Session<CodingAgentSessionInfo>>>;
|
||||
getMostRecentByCwd(cwd: string): Promise<Session<CodingAgentSessionInfo> | undefined>;
|
||||
export interface JsonlSessionCreateOptions extends SessionCreateOptions {
|
||||
parentSessionPath?: string;
|
||||
}
|
||||
|
||||
export interface JsonlSessionRepo<TRef = string>
|
||||
extends SessionRepo<TRef, JsonlSessionInfo, JsonlSessionCreateOptions> {
|
||||
listByCwd(cwd: string): Promise<Array<Session<JsonlSessionInfo>>>;
|
||||
getMostRecentByCwd(cwd: string): Promise<Session<JsonlSessionInfo> | undefined>;
|
||||
}
|
||||
|
||||
export interface AgentHarnessPendingMutations {
|
||||
|
||||
Reference in New Issue
Block a user