{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/recorder/store.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,QAAQ,EAAmB,MAAM,aAAa,CAAC;AAE7D,OAAO,EAA2B,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,KAAK,EACX,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,aAAa,CAAC;AAKrB,MAAM,WAAW,oBAAoB;IACpC,KAAK,EAAE,QAAQ,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,kBAAkB,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,QAAQ,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACxD,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACrD,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;CACpH;AAkID,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,aAAa,CAAC,CAsF/F;AAED,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CAcpG;AAED,wBAAsB,cAAc,CAAC,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAiCjG","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { type FileHandle, open, readFile } from \"node:fs/promises\";\nimport { basename, join } from \"node:path\";\nimport { type EvoPaths, ensureEvoLayout } from \"../paths.ts\";\nimport { appendJsonLine, atomicWriteFile, atomicWriteJson, canonicalJson, sha256, withFileLock } from \"../storage.ts\";\nimport { RECORDER_SCHEMA_VERSION, type StoredPayload } from \"../types.ts\";\nimport type {\n\tRecordedEvent,\n\tRecorderEvent,\n\tRecorderEventData,\n\tRecorderInboxEntry,\n\tRecorderInboxKind,\n} from \"./schema.ts\";\n\nconst DEFAULT_ARTIFACT_THRESHOLD_BYTES = 16 * 1024;\nconst DEFAULT_PREVIEW_CHARACTERS = 1_024;\n\nexport interface RecorderStoreOptions {\n\tpaths: EvoPaths;\n\tsessionId: string;\n\tbundleDigest?: string;\n\tartifactThresholdBytes?: number;\n\tpreviewCharacters?: number;\n\tnow?: () => Date;\n}\n\nexport interface StoredInboxEntry {\n\tentry: RecorderInboxEntry;\n\tpath: string;\n\tfileName: string;\n}\n\nexport interface RecorderStore {\n\tpaths: EvoPaths;\n\tsessionId: string;\n\tbundleDigest: string | null;\n\tlogPath: string;\n\tappend(data: RecorderEventData): Promise<RecorderEvent>;\n\tstorePayload(value: unknown): Promise<StoredPayload>;\n\twriteInbox(text: string, source: RecorderInboxEntry[\"source\"], kind?: RecorderInboxKind): Promise<StoredInboxEntry>;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null;\n}\n\nfunction getErrorCode(error: unknown): string | undefined {\n\tif (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n\treturn typeof error.code === \"string\" ? error.code : undefined;\n}\n\nfunction validateSessionId(sessionId: string): void {\n\tif (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(sessionId)) {\n\t\tthrow new Error(`Invalid recorder session id: ${sessionId}`);\n\t}\n}\n\nfunction recorderLockName(sessionId: string): string {\n\treturn `recorder-${sha256(sessionId)}`;\n}\n\nfunction normalizeForStorage(value: unknown, ancestors: WeakSet<object> = new WeakSet<object>()): unknown {\n\tif (value === null || typeof value === \"string\" || typeof value === \"boolean\") return value;\n\tif (typeof value === \"number\") return Number.isFinite(value) ? value : String(value);\n\tif (typeof value === \"bigint\") return value.toString();\n\tif (typeof value === \"undefined\") return null;\n\tif (typeof value === \"symbol\" || typeof value === \"function\") return String(value);\n\tif (value instanceof Date) return value.toISOString();\n\tif (value instanceof Error) {\n\t\treturn {\n\t\t\tname: value.name,\n\t\t\tmessage: value.message,\n\t\t\tstack: value.stack,\n\t\t};\n\t}\n\tif (value instanceof Uint8Array) {\n\t\treturn {\n\t\t\tencoding: \"base64\",\n\t\t\tbytes: value.byteLength,\n\t\t\tdata: Buffer.from(value).toString(\"base64\"),\n\t\t};\n\t}\n\tif (ancestors.has(value)) return \"[Circular]\";\n\tancestors.add(value);\n\ttry {\n\t\tif (Array.isArray(value)) return value.map((item) => normalizeForStorage(item, ancestors));\n\t\tconst record = value as Record<string, unknown>;\n\t\tconst normalized: Record<string, unknown> = {};\n\t\tfor (const key of Object.keys(record)) {\n\t\t\ttry {\n\t\t\t\tnormalized[key] = normalizeForStorage(record[key], ancestors);\n\t\t\t} catch (error) {\n\t\t\t\tnormalized[key] = `[Unreadable: ${error instanceof Error ? error.message : String(error)}]`;\n\t\t\t}\n\t\t}\n\t\treturn normalized;\n\t} finally {\n\t\tancestors.delete(value);\n\t}\n}\n\nfunction makePreview(content: string, previewCharacters: number): string {\n\tif (content.length <= previewCharacters) return content;\n\treturn `${content.slice(0, previewCharacters)}\\n…`;\n}\n\nfunction readSequence(value: unknown, expected: number, logPath: string, lineNumber: number): number {\n\tif (\n\t\t!isRecord(value) ||\n\t\ttypeof value.sequence !== \"number\" ||\n\t\t!Number.isSafeInteger(value.sequence) ||\n\t\tvalue.sequence !== expected\n\t) {\n\t\tthrow new Error(`Invalid recorder sequence at ${logPath}:${lineNumber}`);\n\t}\n\treturn value.sequence;\n}\n\nfunction parseTerminatedLine(line: string, expected: number, logPath: string, lineNumber: number): number {\n\tlet parsed: unknown;\n\ttry {\n\t\tparsed = JSON.parse(line) as unknown;\n\t} catch (error) {\n\t\tthrow new Error(`Malformed recorder log line at ${logPath}:${lineNumber}`, { cause: error });\n\t}\n\treturn readSequence(parsed, expected, logPath, lineNumber);\n}\n\nasync function readLastSequence(logPath: string): Promise<number> {\n\tlet handle: FileHandle | undefined;\n\ttry {\n\t\thandle = await open(logPath, \"r+\");\n\t} catch (error) {\n\t\tif (getErrorCode(error) === \"ENOENT\") return 0;\n\t\tthrow error;\n\t}\n\n\ttry {\n\t\tconst content = await handle.readFile();\n\t\tif (content.byteLength === 0) return 0;\n\n\t\tconst lastNewline = content.lastIndexOf(0x0a);\n\t\tconst terminatedEnd = lastNewline + 1;\n\t\tconst terminatedLines = content.subarray(0, terminatedEnd).toString(\"utf8\").split(\"\\n\");\n\t\tlet sequence = 0;\n\t\tfor (let index = 0; index < terminatedLines.length - 1; index++) {\n\t\t\tsequence = parseTerminatedLine(terminatedLines[index] ?? \"\", sequence + 1, logPath, index + 1);\n\t\t}\n\n\t\tif (terminatedEnd === content.byteLength) return sequence;\n\n\t\tconst trailingLine = content.subarray(terminatedEnd).toString(\"utf8\");\n\t\tlet parsed: unknown;\n\t\ttry {\n\t\t\tparsed = JSON.parse(trailingLine) as unknown;\n\t\t} catch {\n\t\t\tawait handle.truncate(terminatedEnd);\n\t\t\tawait handle.sync();\n\t\t\treturn sequence;\n\t\t}\n\n\t\tsequence = readSequence(parsed, sequence + 1, logPath, terminatedLines.length);\n\t\tawait handle.write(\"\\n\", content.byteLength, \"utf8\");\n\t\tawait handle.sync();\n\t\treturn sequence;\n\t} finally {\n\t\tawait handle.close();\n\t}\n}\n\nexport async function createRecorderStore(options: RecorderStoreOptions): Promise<RecorderStore> {\n\tvalidateSessionId(options.sessionId);\n\tawait ensureEvoLayout(options.paths);\n\n\tconst logPath = join(options.paths.log, `${options.sessionId}.jsonl`);\n\tconst artifactThresholdBytes = Math.max(1, options.artifactThresholdBytes ?? DEFAULT_ARTIFACT_THRESHOLD_BYTES);\n\tconst previewCharacters = Math.max(1, options.previewCharacters ?? DEFAULT_PREVIEW_CHARACTERS);\n\tconst now = options.now ?? (() => new Date());\n\tconst bundleDigest = options.bundleDigest ?? null;\n\tconst lockName = recorderLockName(options.sessionId);\n\tawait withFileLock(options.paths, lockName, async () => {\n\t\tawait readLastSequence(logPath);\n\t});\n\n\tasync function storePayload(value: unknown): Promise<StoredPayload> {\n\t\tconst normalized = normalizeForStorage(value);\n\t\tconst isText = typeof normalized === \"string\";\n\t\tconst content = isText ? normalized : canonicalJson(normalized);\n\t\tconst preview = makePreview(content, previewCharacters);\n\t\tconst bytes = Buffer.byteLength(content, \"utf8\");\n\t\tif (bytes <= artifactThresholdBytes) {\n\t\t\treturn {\n\t\t\t\tpreview,\n\t\t\t\tvalue: isText ? normalized : (JSON.parse(content) as unknown),\n\t\t\t};\n\t\t}\n\n\t\tconst digest = sha256(content);\n\t\tawait atomicWriteFile(join(options.paths.artifacts, digest), content);\n\t\treturn {\n\t\t\tpreview,\n\t\t\tartifact: {\n\t\t\t\tsha256: digest,\n\t\t\t\tbytes,\n\t\t\t\tmediaType: isText ? \"text/plain\" : \"application/json\",\n\t\t\t},\n\t\t};\n\t}\n\n\tasync function append(data: RecorderEventData): Promise<RecorderEvent> {\n\t\treturn withFileLock(options.paths, lockName, async () => {\n\t\t\tconst nextSequence = (await readLastSequence(logPath)) + 1;\n\t\t\tconst event = {\n\t\t\t\tschemaVersion: RECORDER_SCHEMA_VERSION,\n\t\t\t\tsessionId: options.sessionId,\n\t\t\t\tsequence: nextSequence,\n\t\t\t\ttimestamp: now().toISOString(),\n\t\t\t\tbundleDigest,\n\t\t\t\t...data,\n\t\t\t} as RecorderEvent;\n\t\t\tawait appendJsonLine(logPath, event);\n\t\t\treturn event;\n\t\t});\n\t}\n\n\tasync function writeInbox(\n\t\ttext: string,\n\t\tsource: RecorderInboxEntry[\"source\"],\n\t\tkind?: RecorderInboxKind,\n\t): Promise<StoredInboxEntry> {\n\t\tconst id = randomUUID();\n\t\tconst timestamp = now().toISOString();\n\t\tconst entry: RecorderInboxEntry = {\n\t\t\tschemaVersion: RECORDER_SCHEMA_VERSION,\n\t\t\tid,\n\t\t\ttimestamp,\n\t\t\tsessionId: options.sessionId,\n\t\t\tsource,\n\t\t\ttext,\n\t\t\t...(kind ? { kind } : {}),\n\t\t};\n\t\tconst fileName = `${timestamp.replaceAll(\":\", \"-\")}-${id}.json`;\n\t\tconst path = join(options.paths.inbox, fileName);\n\t\tawait atomicWriteJson(path, entry);\n\t\treturn { entry, path, fileName: basename(path) };\n\t}\n\n\treturn {\n\t\tpaths: options.paths,\n\t\tsessionId: options.sessionId,\n\t\tbundleDigest,\n\t\tlogPath,\n\t\tappend,\n\t\tstorePayload,\n\t\twriteInbox,\n\t};\n}\n\nexport async function resolveStoredPayload(paths: EvoPaths, payload: StoredPayload): Promise<unknown> {\n\tif (\"value\" in payload) return payload.value;\n\tif (!payload.artifact) return payload.preview;\n\tif (!/^[a-f0-9]{64}$/.test(payload.artifact.sha256)) {\n\t\tthrow new Error(`Invalid artifact digest: ${payload.artifact.sha256}`);\n\t}\n\tconst content = await readFile(join(paths.artifacts, payload.artifact.sha256), \"utf8\");\n\tif (sha256(content) !== payload.artifact.sha256) {\n\t\tthrow new Error(`Artifact digest mismatch: ${payload.artifact.sha256}`);\n\t}\n\tif (Buffer.byteLength(content, \"utf8\") !== payload.artifact.bytes) {\n\t\tthrow new Error(`Artifact byte length mismatch: ${payload.artifact.sha256}`);\n\t}\n\treturn payload.artifact.mediaType === \"application/json\" ? (JSON.parse(content) as unknown) : content;\n}\n\nexport async function readSessionLog(paths: EvoPaths, sessionId: string): Promise<RecordedEvent[]> {\n\tvalidateSessionId(sessionId);\n\treturn withFileLock(paths, recorderLockName(sessionId), async () => {\n\t\tconst logPath = join(paths.log, `${sessionId}.jsonl`);\n\t\tawait readLastSequence(logPath);\n\t\tlet content: string;\n\t\ttry {\n\t\t\tcontent = await readFile(logPath, \"utf8\");\n\t\t} catch (error) {\n\t\t\tif (getErrorCode(error) === \"ENOENT\") return [];\n\t\t\tthrow error;\n\t\t}\n\n\t\tconst events: RecordedEvent[] = [];\n\t\tfor (const [index, line] of content.split(\"\\n\").entries()) {\n\t\t\tif (!line.trim()) continue;\n\t\t\tconst parsed = JSON.parse(line) as unknown;\n\t\t\tif (\n\t\t\t\t!isRecord(parsed) ||\n\t\t\t\tparsed.schemaVersion !== RECORDER_SCHEMA_VERSION ||\n\t\t\t\ttypeof parsed.type !== \"string\" ||\n\t\t\t\ttypeof parsed.timestamp !== \"string\" ||\n\t\t\t\tparsed.sessionId !== sessionId ||\n\t\t\t\ttypeof parsed.sequence !== \"number\" ||\n\t\t\t\t!Number.isSafeInteger(parsed.sequence) ||\n\t\t\t\tparsed.sequence !== events.length + 1\n\t\t\t) {\n\t\t\t\tthrow new Error(`Invalid recorder event at ${sessionId}.jsonl:${index + 1}`);\n\t\t\t}\n\t\t\tevents.push(parsed as unknown as RecordedEvent);\n\t\t}\n\t\treturn events;\n\t});\n}\n"]}