import { access, mkdir, readFile, rename, rm } from "node:fs/promises"; import { isAbsolute, dirname, normalize } from "node:path"; import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent"; import type { WorkerForkSource } from "./workflow-types.js"; export interface PiSessionForkInput extends WorkerForkSource { readonly targetSessionFile: string; } export interface PiSessionForker { fork(input: PiSessionForkInput): Promise; } export interface ForkSourceSession { getSessionFile(): string | undefined; getLeafId(): string | null; getBranch(fromId?: string): SessionEntry[]; } export function describeCurrentPiSessionFork(source: ForkSourceSession): WorkerForkSource { const sourceSessionFile = source.getSessionFile(); if (!sourceSessionFile || !isAbsolute(sourceSessionFile)) { throw new Error("Fork context requires a persisted absolute parent Pi session file"); } const branchPointId = source.getLeafId(); if (!branchPointId) throw new Error("Fork context requires a current parent session leaf"); const branch = source.getBranch(branchPointId); if (branch.length === 0 || !branch.some((entry) => entry.id === branchPointId)) { throw new Error("Fork context branch point is absent from the parent session"); } return { sourceSessionFile: normalize(sourceSessionFile), branchPointId, inheritedEntries: branch.length, inheritedBytes: branch.reduce( (total, entry) => total + Buffer.byteLength(`${JSON.stringify(entry)}\n`, "utf8"), 0, ), }; } export class NodePiSessionForker implements PiSessionForker { async fork(input: PiSessionForkInput): Promise { const sourceSessionFile = absolute(input.sourceSessionFile, "fork source session file"); const targetSessionFile = absolute(input.targetSessionFile, "fork target session file"); if (sourceSessionFile === targetSessionFile) { throw new Error("Fork target session file must differ from its parent"); } await assertAbsent(targetSessionFile); await mkdir(dirname(targetSessionFile), { recursive: true, mode: 0o700 }); const sourceBefore = await readFile(sourceSessionFile); const manager = SessionManager.open(sourceSessionFile, dirname(targetSessionFile)); if (!manager.getEntry(input.branchPointId)) { throw new Error(`Fork branch point ${input.branchPointId} is absent from the parent session`); } let generated: string | undefined; try { generated = manager.createBranchedSession(input.branchPointId); if (!generated) throw new Error("Pi session fork did not produce a persisted child session"); await access(generated); await rename(generated, targetSessionFile); generated = undefined; } finally { if (generated) await rm(generated, { force: true }).catch(() => {}); } const child = SessionManager.open(targetSessionFile, dirname(targetSessionFile)); if (child.getHeader()?.parentSession !== sourceSessionFile) { throw new Error("Fork child session does not record the expected parent session"); } if (!child.getEntry(input.branchPointId)) { throw new Error("Fork child session does not contain the requested branch point"); } const sourceAfter = await readFile(sourceSessionFile); if (!sourceAfter.equals(sourceBefore)) { throw new Error("Forking mutated the parent Pi session file"); } return targetSessionFile; } } function absolute(value: string, label: string): string { if (!isAbsolute(value)) throw new TypeError(`${label} must be absolute`); return normalize(value); } async function assertAbsent(path: string): Promise { try { await access(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; } throw new Error(`Fork target session file already exists: ${path}`); }