{"version":3,"file":"session-persistence.d.ts","sourceRoot":"","sources":["../src/session-persistence.ts"],"names":[],"mappings":"AAeA,OAAO,EAKN,KAAK,YAAY,EAEjB,MAAM,oBAAoB,CAAC;AAkP5B,wBAAgB,qBAAqB,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI,CAsBnE;AAkCD,wBAAgB,oBAAoB,CAAC,eAAe,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,IAAI,CAazF;AAED,wBAAgB,sBAAsB,CAAC,eAAe,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAIvF","sourcesContent":["import { Buffer } from \"node:buffer\";\nimport { createHash, randomUUID } from \"node:crypto\";\nimport {\n\tappendFileSync,\n\texistsSync,\n\tmkdirSync,\n\treaddirSync,\n\treadFileSync,\n\trenameSync,\n\trmSync,\n\ttruncateSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { join } from \"node:path\";\nimport type { SessionTreeEntry } from \"@earendil-works/pi-agent-core\";\nimport {\n\texportSessionState,\n\tmarkSessionPersisted,\n\ttype PersistedSessionState,\n\trestoreSessionState,\n\ttype SessionState,\n\ttype SessionStaticContext,\n} from \"./session-store.ts\";\n\nconst PERSISTED_SESSION_VERSION = 1;\nconst WAL_SNAPSHOT_INTERVAL = 32;\nconst REPLACE_RETRY_DELAYS_MS = [25, 50, 100, 200, 400];\n\ninterface PersistedSessionFile {\n\tversion: 1;\n\tsession: PersistedSessionState;\n}\n\ninterface PersistedSessionWalRecord {\n\tversion: 1;\n\tsessionId: string;\n\tbaseEntryCount: number;\n\tentries: SessionTreeEntry[];\n\tleafId: string | null;\n\trevision: number;\n\tupdatedAt: number;\n\tstaticContext: SessionStaticContext | undefined;\n}\n\ninterface PersistedSessionMeta {\n\tentryCount: number;\n\twalRecords: number;\n}\n\nconst persistedSessions = new Map<string, PersistedSessionMeta>();\n\nfunction sessionFileName(sessionId: string): string {\n\treturn `${createHash(\"sha256\").update(sessionId).digest(\"hex\")}.json`;\n}\n\nfunction sessionPath(sessionStoreDir: string, sessionId: string): string {\n\treturn join(sessionStoreDir, sessionFileName(sessionId));\n}\n\nfunction walPath(sessionStoreDir: string, sessionId: string): string {\n\treturn `${sessionPath(sessionStoreDir, sessionId)}.wal`;\n}\n\nfunction persistedSessionKey(sessionStoreDir: string, sessionId: string): string {\n\treturn `${sessionStoreDir}\\0${sessionId}`;\n}\n\nfunction isRetryableRenameError(error: unknown): boolean {\n\tif (!(error instanceof Error)) return false;\n\tconst code = (error as NodeJS.ErrnoException).code;\n\treturn process.platform === \"win32\" && code === \"EPERM\";\n}\n\nfunction sleepSync(ms: number): void {\n\tAtomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n}\n\nfunction replaceFileSync(sourcePath: string, targetPath: string): void {\n\tfor (let attempt = 0; attempt <= REPLACE_RETRY_DELAYS_MS.length; attempt++) {\n\t\ttry {\n\t\t\trenameSync(sourcePath, targetPath);\n\t\t\treturn;\n\t\t} catch (error) {\n\t\t\tif (!isRetryableRenameError(error) || attempt === REPLACE_RETRY_DELAYS_MS.length) {\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t\tsleepSync(REPLACE_RETRY_DELAYS_MS[attempt]);\n\t\t}\n\t}\n}\n\nfunction assertRecord(value: unknown, path: string): asserts value is Record<string, unknown> {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(`${path} must be an object`);\n\t}\n}\n\nfunction assertString(value: unknown, path: string): asserts value is string {\n\tif (typeof value !== \"string\") {\n\t\tthrow new Error(`${path} must be a string`);\n\t}\n}\n\nfunction assertNumber(value: unknown, path: string): asserts value is number {\n\tif (typeof value !== \"number\" || !Number.isFinite(value)) {\n\t\tthrow new Error(`${path} must be a finite number`);\n\t}\n}\n\nfunction assertNullableString(value: unknown, path: string): asserts value is string | null {\n\tif (value !== null && typeof value !== \"string\") {\n\t\tthrow new Error(`${path} must be a string or null`);\n\t}\n}\n\nfunction assertStaticContext(value: unknown): asserts value is SessionStaticContext | undefined {\n\tif (value === undefined) return;\n\tassertRecord(value, \"session.staticContext\");\n\tconst systemPrompt = value.systemPrompt;\n\tif (systemPrompt !== undefined && typeof systemPrompt !== \"string\") {\n\t\tthrow new Error(\"session.staticContext.systemPrompt must be a string\");\n\t}\n\tconst tools = value.tools;\n\tif (tools !== undefined) {\n\t\tif (!Array.isArray(tools)) {\n\t\t\tthrow new Error(\"session.staticContext.tools must be an array\");\n\t\t}\n\t\tfor (const [index, tool] of tools.entries()) {\n\t\t\tassertRecord(tool, `session.staticContext.tools[${index}]`);\n\t\t\tassertString(tool.name, `session.staticContext.tools[${index}].name`);\n\t\t\tassertString(tool.description, `session.staticContext.tools[${index}].description`);\n\t\t\tif (tool.parameters === undefined) {\n\t\t\t\tthrow new Error(`session.staticContext.tools[${index}].parameters is required`);\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunction assertSessionTreeEntries(value: unknown): asserts value is SessionTreeEntry[] {\n\tif (!Array.isArray(value)) {\n\t\tthrow new Error(\"session.entries must be an array\");\n\t}\n\tfor (const [index, entry] of value.entries()) {\n\t\tassertRecord(entry, `session.entries[${index}]`);\n\t\tassertString(entry.type, `session.entries[${index}].type`);\n\t\tassertString(entry.id, `session.entries[${index}].id`);\n\t\tassertNullableString(entry.parentId, `session.entries[${index}].parentId`);\n\t\tassertString(entry.timestamp, `session.entries[${index}].timestamp`);\n\t\tif (entry.type === \"message\") {\n\t\t\tassertRecord(entry.message, `session.entries[${index}].message`);\n\t\t}\n\t}\n}\n\nfunction assertPersistedSessionState(value: unknown): asserts value is PersistedSessionState {\n\tassertRecord(value, \"session\");\n\tassertString(value.sessionId, \"session.sessionId\");\n\tassertStaticContext(value.staticContext);\n\tassertSessionTreeEntries(value.entries);\n\tassertNullableString(value.leafId, \"session.leafId\");\n\tassertNumber(value.revision, \"session.revision\");\n\tassertNumber(value.createdAt, \"session.createdAt\");\n\tassertNumber(value.updatedAt, \"session.updatedAt\");\n}\n\nfunction assertPersistedWalRecord(\n\tvalue: unknown,\n\tsourcePath: string,\n\tlineNumber: number,\n): asserts value is PersistedSessionWalRecord {\n\tconst path = `${sourcePath}:${lineNumber}`;\n\tassertRecord(value, path);\n\tif (value.version !== PERSISTED_SESSION_VERSION) {\n\t\tthrow new Error(`Unsupported persisted session WAL version in ${path}`);\n\t}\n\tassertString(value.sessionId, `${path}.sessionId`);\n\tassertNumber(value.baseEntryCount, `${path}.baseEntryCount`);\n\tassertSessionTreeEntries(value.entries);\n\tassertNullableString(value.leafId, `${path}.leafId`);\n\tassertNumber(value.revision, `${path}.revision`);\n\tassertNumber(value.updatedAt, `${path}.updatedAt`);\n\tassertStaticContext(value.staticContext);\n}\n\nfunction parsePersistedSessionFile(raw: string, sourcePath: string): PersistedSessionFile {\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(raw);\n\t} catch (error) {\n\t\tthrow new Error(`Persisted session file contains invalid JSON: ${sourcePath}`, { cause: error });\n\t}\n\tassertRecord(parsed, \"persisted session file\");\n\tif (parsed.version !== PERSISTED_SESSION_VERSION) {\n\t\tthrow new Error(`Unsupported persisted session version in ${sourcePath}`);\n\t}\n\tassertPersistedSessionState(parsed.session);\n\treturn {\n\t\tversion: PERSISTED_SESSION_VERSION,\n\t\tsession: parsed.session,\n\t};\n}\n\nclass PersistedSessionWalSyntaxError extends Error {\n\tconstructor(sourcePath: string, lineNumber: number, cause: unknown) {\n\t\tsuper(`Persisted session WAL contains invalid JSON: ${sourcePath}:${lineNumber}`, { cause });\n\t\tthis.name = \"PersistedSessionWalSyntaxError\";\n\t}\n}\n\nfunction parseWalLine(raw: string, sourcePath: string, lineNumber: number): PersistedSessionWalRecord {\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(raw);\n\t} catch (error) {\n\t\tthrow new PersistedSessionWalSyntaxError(sourcePath, lineNumber, error);\n\t}\n\tassertPersistedWalRecord(parsed, sourcePath, lineNumber);\n\treturn parsed;\n}\n\nfunction applyWalRecord(session: PersistedSessionState, record: PersistedSessionWalRecord): void {\n\tif (record.sessionId !== session.sessionId) {\n\t\tthrow new Error(`Persisted session WAL sessionId does not match snapshot: ${record.sessionId}`);\n\t}\n\tif (record.revision <= session.revision) return;\n\tif (record.baseEntryCount < session.entries.length) {\n\t\tif (record.baseEntryCount + record.entries.length <= session.entries.length) return;\n\t\tthrow new Error(`Persisted session WAL overlaps snapshot for ${record.sessionId}`);\n\t}\n\tif (record.baseEntryCount !== session.entries.length) {\n\t\tthrow new Error(`Persisted session WAL has a gap for ${record.sessionId}`);\n\t}\n\tsession.entries.push(...record.entries.map((entry) => ({ ...entry })));\n\tsession.leafId = record.leafId;\n\tsession.revision = record.revision;\n\tsession.updatedAt = record.updatedAt;\n\tsession.staticContext = record.staticContext;\n}\n\nfunction applyPersistedWal(sessionStoreDir: string, session: PersistedSessionState): number {\n\tconst filePath = walPath(sessionStoreDir, session.sessionId);\n\tif (!existsSync(filePath)) return 0;\n\tconst lines = readFileSync(filePath, \"utf-8\").split(\"\\n\");\n\tlet applied = 0;\n\tfor (const [index, line] of lines.entries()) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tapplyWalRecord(session, parseWalLine(line, filePath, index + 1));\n\t\t\tapplied++;\n\t\t} catch (error) {\n\t\t\tconst isPhysicalTail = lines.slice(index + 1).every((remainingLine) => !remainingLine.trim());\n\t\t\tif (isPhysicalTail && error instanceof PersistedSessionWalSyntaxError) {\n\t\t\t\tconst prefix = lines.slice(0, index).join(\"\\n\");\n\t\t\t\tconst truncateAt = Buffer.byteLength(prefix, \"utf8\") + (index > 0 ? 1 : 0);\n\t\t\t\ttruncateSync(filePath, truncateAt);\n\t\t\t\tconsole.warn(`Discarding torn WAL tail line at ${filePath}:${index + 1}`);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t}\n\treturn applied;\n}\n\nexport function loadPersistedSessions(sessionStoreDir: string): void {\n\tif (!existsSync(sessionStoreDir)) return;\n\tconst entries = readdirSync(sessionStoreDir, { withFileTypes: true });\n\tfor (const entry of entries) {\n\t\tif (!entry.isFile() || !entry.name.endsWith(\".json\")) continue;\n\t\tconst filePath = join(sessionStoreDir, entry.name);\n\t\ttry {\n\t\t\tconst persisted = parsePersistedSessionFile(readFileSync(filePath, \"utf-8\"), filePath);\n\t\t\tconst expectedFileName = sessionFileName(persisted.session.sessionId);\n\t\t\tif (entry.name !== expectedFileName) {\n\t\t\t\tthrow new Error(`Persisted session file name does not match sessionId: ${filePath}`);\n\t\t\t}\n\t\t\tconst walRecords = applyPersistedWal(sessionStoreDir, persisted.session);\n\t\t\trestoreSessionState(persisted.session);\n\t\t\tpersistedSessions.set(persistedSessionKey(sessionStoreDir, persisted.session.sessionId), {\n\t\t\t\tentryCount: persisted.session.entries.length,\n\t\t\t\twalRecords,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tconsole.error(`Failed to load session from ${filePath}, skipping:`, error);\n\t\t}\n\t}\n}\n\nfunction writeSnapshot(sessionStoreDir: string, session: SessionState): void {\n\tmkdirSync(sessionStoreDir, { recursive: true });\n\tconst filePath = sessionPath(sessionStoreDir, session.sessionId);\n\tconst tempPath = `${filePath}.${randomUUID()}.tmp`;\n\tconst body: PersistedSessionFile = {\n\t\tversion: PERSISTED_SESSION_VERSION,\n\t\tsession: exportSessionState(session),\n\t};\n\ttry {\n\t\twriteFileSync(tempPath, JSON.stringify(body), \"utf-8\");\n\t\treplaceFileSync(tempPath, filePath);\n\t\trmSync(walPath(sessionStoreDir, session.sessionId), { force: true });\n\t} catch (error) {\n\t\trmSync(tempPath, { force: true });\n\t\tthrow error;\n\t}\n}\n\nfunction appendWalRecord(sessionStoreDir: string, session: SessionState, baseEntryCount: number): void {\n\tconst record: PersistedSessionWalRecord = {\n\t\tversion: PERSISTED_SESSION_VERSION,\n\t\tsessionId: session.sessionId,\n\t\tbaseEntryCount,\n\t\tentries: session.persistenceChange?.kind === \"wal\" ? session.persistenceChange.entries : [],\n\t\tleafId: session.leafId,\n\t\trevision: session.revision,\n\t\tupdatedAt: session.updatedAt,\n\t\tstaticContext: session.staticContext,\n\t};\n\tappendFileSync(walPath(sessionStoreDir, session.sessionId), `${JSON.stringify(record)}\\n`, \"utf-8\");\n}\n\nexport function savePersistedSession(sessionStoreDir: string, session: SessionState): void {\n\tconst key = persistedSessionKey(sessionStoreDir, session.sessionId);\n\tconst meta = persistedSessions.get(key);\n\tif (meta && !session.persistenceChange) return;\n\tif (!meta || session.persistenceChange?.kind === \"snapshot\" || meta.walRecords >= WAL_SNAPSHOT_INTERVAL) {\n\t\twriteSnapshot(sessionStoreDir, session);\n\t\tpersistedSessions.set(key, { entryCount: session.entries.length, walRecords: 0 });\n\t\tmarkSessionPersisted(session);\n\t\treturn;\n\t}\n\tappendWalRecord(sessionStoreDir, session, meta.entryCount);\n\tpersistedSessions.set(key, { entryCount: session.entries.length, walRecords: meta.walRecords + 1 });\n\tmarkSessionPersisted(session);\n}\n\nexport function deletePersistedSession(sessionStoreDir: string, sessionId: string): void {\n\trmSync(sessionPath(sessionStoreDir, sessionId), { force: true });\n\trmSync(walPath(sessionStoreDir, sessionId), { force: true });\n\tpersistedSessions.delete(persistedSessionKey(sessionStoreDir, sessionId));\n}\n"]}