refactor(agent): harden harness session semantics
This commit is contained in:
@@ -6,9 +6,15 @@ import type {
|
||||
JsonlSessionRepoApi,
|
||||
Session,
|
||||
} from "../types.js";
|
||||
import { getOrThrow } from "../types.js";
|
||||
import { SessionError, toError } from "../types.js";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js";
|
||||
import {
|
||||
createSessionId,
|
||||
createTimestamp,
|
||||
getEntriesToFork,
|
||||
getFileSystemResultOrThrow,
|
||||
toSession,
|
||||
} from "./repo-utils.js";
|
||||
|
||||
type JsonlSessionRepoFileSystem = Pick<
|
||||
FileSystem,
|
||||
@@ -16,6 +22,7 @@ type JsonlSessionRepoFileSystem = Pick<
|
||||
| "absolutePath"
|
||||
| "joinPath"
|
||||
| "readTextFile"
|
||||
| "readTextLines"
|
||||
| "writeFile"
|
||||
| "appendFile"
|
||||
| "listDir"
|
||||
@@ -40,21 +47,28 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
|
||||
private async getSessionsRoot(): Promise<string> {
|
||||
if (!this.sessionsRoot) {
|
||||
this.sessionsRoot = getOrThrow(await this.fs.absolutePath(this.sessionsRootInput));
|
||||
this.sessionsRoot = getFileSystemResultOrThrow(
|
||||
await this.fs.absolutePath(this.sessionsRootInput),
|
||||
`Failed to resolve sessions root ${this.sessionsRootInput}`,
|
||||
);
|
||||
}
|
||||
return this.sessionsRoot;
|
||||
}
|
||||
|
||||
private async getSessionDir(cwd: string): Promise<string> {
|
||||
return getOrThrow(await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]));
|
||||
return getFileSystemResultOrThrow(
|
||||
await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]),
|
||||
`Failed to resolve session directory for ${cwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise<string> {
|
||||
return getOrThrow(
|
||||
return getFileSystemResultOrThrow(
|
||||
await this.fs.joinPath([
|
||||
await this.getSessionDir(cwd),
|
||||
`${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
|
||||
]),
|
||||
`Failed to resolve session file path for ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +76,10 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const sessionDir = await this.getSessionDir(options.cwd);
|
||||
getOrThrow(await this.fs.createDir(sessionDir, { recursive: true }));
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.createDir(sessionDir, { recursive: true }),
|
||||
`Failed to create session directory ${sessionDir}`,
|
||||
);
|
||||
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
|
||||
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
|
||||
cwd: options.cwd,
|
||||
@@ -73,8 +90,10 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
}
|
||||
|
||||
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
|
||||
if (!getOrThrow(await this.fs.exists(metadata.path))) {
|
||||
throw new Error(`Session not found: ${metadata.path}`);
|
||||
if (
|
||||
!getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)
|
||||
) {
|
||||
throw new SessionError("not_found", `Session not found: ${metadata.path}`);
|
||||
}
|
||||
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
|
||||
return toSession(storage);
|
||||
@@ -84,15 +103,19 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs();
|
||||
const sessions: JsonlSessionMetadata[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (!getOrThrow(await this.fs.exists(dir))) continue;
|
||||
const files = getOrThrow(await this.fs.listDir(dir)).filter(
|
||||
(file) => file.kind !== "directory" && file.name.endsWith(".jsonl"),
|
||||
);
|
||||
if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) {
|
||||
continue;
|
||||
}
|
||||
const files = getFileSystemResultOrThrow(
|
||||
await this.fs.listDir(dir),
|
||||
`Failed to list sessions in ${dir}`,
|
||||
).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl"));
|
||||
for (const file of files) {
|
||||
try {
|
||||
sessions.push(await loadJsonlSessionMetadata(this.fs, file.path));
|
||||
} catch {
|
||||
// Ignore invalid session files when listing a directory.
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
if (!(cause instanceof SessionError) || cause.code !== "invalid_session") throw cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +124,10 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
}
|
||||
|
||||
async delete(metadata: JsonlSessionMetadata): Promise<void> {
|
||||
getOrThrow(await this.fs.remove(metadata.path, { force: true }));
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.remove(metadata.path, { force: true }),
|
||||
`Failed to delete session ${metadata.path}`,
|
||||
);
|
||||
}
|
||||
|
||||
async fork(
|
||||
@@ -113,7 +139,10 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const sessionDir = await this.getSessionDir(options.cwd);
|
||||
getOrThrow(await this.fs.createDir(sessionDir, { recursive: true }));
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.createDir(sessionDir, { recursive: true }),
|
||||
`Failed to create session directory ${sessionDir}`,
|
||||
);
|
||||
const storage = await JsonlSessionStorage.create(
|
||||
this.fs,
|
||||
await this.createSessionFilePath(options.cwd, id, createdAt),
|
||||
@@ -131,8 +160,18 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
|
||||
private async listSessionDirs(): Promise<string[]> {
|
||||
const sessionsRoot = await this.getSessionsRoot();
|
||||
if (!getOrThrow(await this.fs.exists(sessionsRoot))) return [];
|
||||
const entries = getOrThrow(await this.fs.listDir(sessionsRoot));
|
||||
if (
|
||||
!getFileSystemResultOrThrow(
|
||||
await this.fs.exists(sessionsRoot),
|
||||
`Failed to check sessions root ${sessionsRoot}`,
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const entries = getFileSystemResultOrThrow(
|
||||
await this.fs.listDir(sessionsRoot),
|
||||
`Failed to list sessions root ${sessionsRoot}`,
|
||||
);
|
||||
return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FileSystem, JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
|
||||
import { getOrThrow } from "../types.js";
|
||||
import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.js";
|
||||
import { SessionError, toError } from "../types.js";
|
||||
import { getFileSystemResultOrThrow } from "./repo-utils.js";
|
||||
import { uuidv7 } from "./uuid.js";
|
||||
|
||||
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "writeFile" | "appendFile">;
|
||||
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "readTextLines" | "writeFile" | "appendFile">;
|
||||
|
||||
interface SessionHeader {
|
||||
type: "session";
|
||||
@@ -39,6 +40,76 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function invalidSession(filePath: string, message: string, cause?: Error): SessionError {
|
||||
return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause);
|
||||
}
|
||||
|
||||
function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError {
|
||||
return new SessionError(
|
||||
"invalid_entry",
|
||||
`Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
|
||||
function parseHeaderLine(line: string, filePath: string): SessionHeader {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
|
||||
}
|
||||
if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header");
|
||||
if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header");
|
||||
if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version");
|
||||
if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id");
|
||||
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
|
||||
throw invalidSession(filePath, "session header is missing timestamp");
|
||||
}
|
||||
if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd");
|
||||
if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") {
|
||||
throw invalidSession(filePath, "session header parentSession must be a string");
|
||||
}
|
||||
return {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: parsed.id,
|
||||
timestamp: parsed.timestamp,
|
||||
cwd: parsed.cwd,
|
||||
parentSession: parsed.parentSession,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
|
||||
}
|
||||
if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
|
||||
if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type");
|
||||
if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id");
|
||||
if (parsed.parentId !== null && typeof parsed.parentId !== "string") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid parentId");
|
||||
}
|
||||
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
|
||||
throw invalidEntry(filePath, lineNumber, "is missing timestamp");
|
||||
}
|
||||
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
|
||||
}
|
||||
return parsed as unknown as SessionTreeEntry;
|
||||
}
|
||||
|
||||
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||
return entry.type === "leaf" ? entry.targetId : entry.id;
|
||||
}
|
||||
|
||||
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
|
||||
return {
|
||||
id: header.id,
|
||||
@@ -53,17 +124,13 @@ export async function loadJsonlSessionMetadata(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
): Promise<JsonlSessionMetadata> {
|
||||
const content = getOrThrow(await fs.readTextFile(filePath));
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) break;
|
||||
try {
|
||||
const header = JSON.parse(line) as SessionHeader;
|
||||
return headerToSessionMetadata(header, filePath);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
const lines = getFileSystemResultOrThrow(
|
||||
await fs.readTextLines(filePath, { maxLines: 1 }),
|
||||
`Failed to read session header ${filePath}`,
|
||||
);
|
||||
const line = lines[0];
|
||||
if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath);
|
||||
throw invalidSession(filePath, "missing session header");
|
||||
}
|
||||
|
||||
async function loadJsonlStorage(
|
||||
@@ -74,29 +141,19 @@ async function loadJsonlStorage(
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
}> {
|
||||
const content = getOrThrow(await fs.readTextFile(filePath));
|
||||
const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`);
|
||||
const lines = content.split("\n").filter((line) => line.trim());
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
}
|
||||
|
||||
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`);
|
||||
throw invalidSession(filePath, "missing session header");
|
||||
}
|
||||
|
||||
const header = parseHeaderLine(lines[0]!, filePath);
|
||||
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
|
||||
}
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const entry = parseEntryLine(lines[i]!, filePath, i + 1);
|
||||
entries.push(entry);
|
||||
leafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
return { header, entries, leafId };
|
||||
}
|
||||
@@ -148,7 +205,10 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
cwd: options.cwd,
|
||||
parentSession: options.parentSessionPath,
|
||||
};
|
||||
getOrThrow(await fs.writeFile(filePath, `${JSON.stringify(header)}\n`));
|
||||
getFileSystemResultOrThrow(
|
||||
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
|
||||
`Failed to create session ${filePath}`,
|
||||
);
|
||||
return new JsonlSessionStorage(fs, filePath, header, [], null);
|
||||
}
|
||||
|
||||
@@ -157,13 +217,29 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
}
|
||||
|
||||
async getLeafId(): Promise<string | null> {
|
||||
if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) {
|
||||
throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`);
|
||||
}
|
||||
return this.currentLeafId;
|
||||
}
|
||||
|
||||
async setLeafId(leafId: string | null): Promise<void> {
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new Error(`Entry ${leafId} not found`);
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
const entry: LeafEntry = {
|
||||
type: "leaf",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.currentLeafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: leafId,
|
||||
};
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
|
||||
`Failed to append session leaf ${entry.id}`,
|
||||
);
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
@@ -172,11 +248,14 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
}
|
||||
|
||||
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
getOrThrow(await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`));
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
|
||||
`Failed to append session entry ${entry.id}`,
|
||||
);
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
updateLabelCache(this.labelsById, entry);
|
||||
this.currentLeafId = entry.id;
|
||||
this.currentLeafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
@@ -197,9 +276,13 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
|
||||
if (leafId === null) return [];
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let current = this.byId.get(leafId);
|
||||
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
||||
if (!current.parentId) break;
|
||||
const parent = this.byId.get(current.parentId);
|
||||
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Session, SessionMetadata, SessionRepo } from "../types.js";
|
||||
import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.js";
|
||||
import { InMemorySessionStorage } from "./memory-storage.js";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js";
|
||||
|
||||
@@ -19,7 +19,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
|
||||
async open(metadata: SessionMetadata): Promise<Session<SessionMetadata>> {
|
||||
const session = this.sessions.get(metadata.id);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${metadata.id}`);
|
||||
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -42,8 +42,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
|
||||
id: options.id ?? createSessionId(),
|
||||
createdAt: createTimestamp(),
|
||||
};
|
||||
const leafId = forkedEntries[forkedEntries.length - 1]?.id ?? null;
|
||||
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries, leafId });
|
||||
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries });
|
||||
const session = toSession(storage);
|
||||
this.sessions.set(metadata.id, session);
|
||||
return session;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
|
||||
import {
|
||||
type LeafEntry,
|
||||
SessionError,
|
||||
type SessionMetadata,
|
||||
type SessionStorage,
|
||||
type SessionTreeEntry,
|
||||
} from "../types.js";
|
||||
import { uuidv7 } from "./uuid.js";
|
||||
|
||||
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
|
||||
@@ -27,36 +33,55 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
export class InMemorySessionStorage implements SessionStorage {
|
||||
private readonly metadata: SessionMetadata;
|
||||
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||
return entry.type === "leaf" ? entry.targetId : entry.id;
|
||||
}
|
||||
|
||||
export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionMetadata>
|
||||
implements SessionStorage<TMetadata>
|
||||
{
|
||||
private readonly metadata: TMetadata;
|
||||
private entries: SessionTreeEntry[];
|
||||
private byId: Map<string, SessionTreeEntry>;
|
||||
private labelsById: Map<string, string>;
|
||||
private leafId: string | null;
|
||||
|
||||
constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; metadata?: SessionMetadata }) {
|
||||
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
|
||||
this.entries = options?.entries ? [...options.entries] : [];
|
||||
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
|
||||
this.labelsById = buildLabelsById(this.entries);
|
||||
this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null;
|
||||
this.leafId = null;
|
||||
for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry);
|
||||
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
||||
throw new Error(`Entry ${this.leafId} not found`);
|
||||
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
||||
}
|
||||
this.metadata = options?.metadata ?? { id: uuidv7(), createdAt: new Date().toISOString() };
|
||||
this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata);
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<SessionMetadata> {
|
||||
async getMetadata(): Promise<TMetadata> {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
async getLeafId(): Promise<string | null> {
|
||||
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
||||
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
||||
}
|
||||
return this.leafId;
|
||||
}
|
||||
|
||||
async setLeafId(leafId: string | null): Promise<void> {
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new Error(`Entry ${leafId} not found`);
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
const entry: LeafEntry = {
|
||||
type: "leaf",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: leafId,
|
||||
};
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
this.leafId = leafId;
|
||||
}
|
||||
|
||||
@@ -68,7 +93,7 @@ export class InMemorySessionStorage implements SessionStorage {
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
updateLabelCache(this.labelsById, entry);
|
||||
this.leafId = entry.id;
|
||||
this.leafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
@@ -89,9 +114,13 @@ export class InMemorySessionStorage implements SessionStorage {
|
||||
if (leafId === null) return [];
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let current = this.byId.get(leafId);
|
||||
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
||||
if (!current.parentId) break;
|
||||
const parent = this.byId.get(current.parentId);
|
||||
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
|
||||
import {
|
||||
type FileError,
|
||||
type Result,
|
||||
SessionError,
|
||||
type SessionMetadata,
|
||||
type SessionStorage,
|
||||
type SessionTreeEntry,
|
||||
} from "../types.js";
|
||||
import { Session } from "./session.js";
|
||||
import { uuidv7 } from "./uuid.js";
|
||||
|
||||
@@ -14,6 +21,14 @@ export function toSession<TMetadata extends SessionMetadata>(storage: SessionSto
|
||||
return new Session(storage);
|
||||
}
|
||||
|
||||
export function getFileSystemResultOrThrow<TValue>(result: Result<TValue, FileError>, message: string): TValue {
|
||||
if (!result.ok) {
|
||||
const code = result.error.code === "not_found" ? "not_found" : "storage";
|
||||
throw new SessionError(code, `${message}: ${result.error.message}`, result.error);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function getEntriesToFork(
|
||||
storage: SessionStorage,
|
||||
options: { entryId?: string; position?: "before" | "at" },
|
||||
@@ -21,14 +36,14 @@ export async function getEntriesToFork(
|
||||
if (!options.entryId) return storage.getEntries();
|
||||
const target = await storage.getEntry(options.entryId);
|
||||
if (!target) {
|
||||
throw new Error(`Entry ${options.entryId} not found`);
|
||||
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`);
|
||||
}
|
||||
let effectiveLeafId: string | null;
|
||||
if ((options.position ?? "before") === "at") {
|
||||
effectiveLeafId = target.id;
|
||||
} else {
|
||||
if (target.type !== "message" || target.message.role !== "user") {
|
||||
throw new Error(`Entry ${options.entryId} is not a user message`);
|
||||
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} is not a user message`);
|
||||
}
|
||||
effectiveLeafId = target.parentId;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SessionTreeEntry,
|
||||
ThinkingLevelChangeEntry,
|
||||
} from "../types.js";
|
||||
import { SessionError } from "../types.js";
|
||||
|
||||
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
|
||||
let thinkingLevel = "off";
|
||||
@@ -206,7 +207,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
|
||||
async appendLabel(targetId: string, label: string | undefined): Promise<string> {
|
||||
if (!(await this.storage.getEntry(targetId))) {
|
||||
throw new Error(`Entry ${targetId} not found`);
|
||||
throw new SessionError("not_found", `Entry ${targetId} not found`);
|
||||
}
|
||||
return this.appendTypedEntry({
|
||||
type: "label",
|
||||
@@ -233,7 +234,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
summary?: { summary: string; details?: unknown; fromHook?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
|
||||
throw new Error(`Entry ${entryId} not found`);
|
||||
throw new SessionError("not_found", `Entry ${entryId} not found`);
|
||||
}
|
||||
await this.storage.setLeafId(entryId);
|
||||
if (!summary) return undefined;
|
||||
|
||||
Reference in New Issue
Block a user