=================================================================== --- a/src/main/claude/claude-transcript-branch-proof.ts +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -1,1 +1,2 @@ -import { readFile } from 'node:fs/promises' +import { open } from 'node:fs/promises' +import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines' @@ -4,0 +6,6 @@ +class ClaudeTranscriptMarkerMissingError extends Error { + constructor() { + super('Claude transcript branch proof failed: missing last-prompt marker') + } +} + @@ -82,2 +89,1 @@ -export function proveClaudeTranscriptBranchFromJsonl(input: { - contents: string +type BranchProofInput = { @@ -87,1 +93,3 @@ -}): ClaudeTranscriptBranchProof { +} + +function createBranchProof(input: BranchProofInput) { @@ -91,2 +99,3 @@ - const lines = input.contents.split('\n') - for (const [index, line] of lines.entries()) { + return { add, finish } + + function add(line: string, index: number, terminated: boolean): void { @@ -94,1 +103,1 @@ - continue + return @@ -100,1 +109,1 @@ - if (index === lines.length - 1 && !input.contents.endsWith('\n')) { + if (!terminated) { @@ -107,0 +117,1 @@ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The parsed value is a non-array object checked above. @@ -120,1 +130,1 @@ - continue + return @@ -149,14 +159,4 @@ - if (!leafUuid) { - throw transcriptError('missing last-prompt marker') - } - const leaf = nodes.get(leafUuid) - if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) { - throw transcriptError('marker leaf is missing from the session graph') - } - if (leaf.lineIndex > leafMarkerLineIndex) { - throw transcriptError('marker precedes its leaf record') - } - const previousLeafUuid = input.previousLeafUuid - if (input.intentionalRewindUuid !== undefined) { - if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) { - throw transcriptError('rewind target does not match the observed leaf') + + function finish(): ClaudeTranscriptBranchProof { + if (!leafUuid) { + throw new ClaudeTranscriptMarkerMissingError() @@ -164,6 +164,11 @@ - proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId) - proveAppendOrder(nodes) - let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null - for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { - if (ancestor === leafUuid) { - return { leafUuid, relation: 'intentional-rewind' } + const leaf = nodes.get(leafUuid) + if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) { + throw transcriptError('marker leaf is missing from the session graph') + } + if (leaf.lineIndex > leafMarkerLineIndex) { + throw transcriptError('marker precedes its leaf record') + } + const previousLeafUuid = input.previousLeafUuid + if (input.intentionalRewindUuid !== undefined) { + if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) { + throw transcriptError('rewind target does not match the observed leaf') @@ -171,1 +176,10 @@ - ancestor = nodes.get(ancestor)?.parentUuid ?? null + proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId) + proveAppendOrder(nodes) + let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null + for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { + if (ancestor === leafUuid) { + return { leafUuid, relation: 'intentional-rewind' } + } + ancestor = nodes.get(ancestor)?.parentUuid ?? null + } + throw transcriptError('rewind target is not an ancestor of the previous cursor') @@ -173,31 +187,7 @@ - throw transcriptError('rewind target is not an ancestor of the previous cursor') - } - if (!previousLeafUuid) { - proveMainLineAncestry(nodes, leafUuid, input.providerSessionId) - // A branch proof is based on an append-only snapshot. A child that appears - // before its claimed parent is not a post-snapshot descendant observation; - // accepting that graph would turn reordered/torn rows into durable ancestry. - proveAppendOrder(nodes) - return { leafUuid, relation: 'initial' } - } - const previous = nodes.get(previousLeafUuid) - if (!previous) { - throw new ClaudeTranscriptPreviousCursorMissingError() - } - if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) { - throw transcriptError('previous cursor is not on the main transcript') - } - // The latest marker can be equal to, or descend from, a sampled cursor. In - // either case prove the sampled cursor's own ancestry before accepting it; - // otherwise a cursor that descended through a parent-tool-use sidechain - // could be persisted and resumed as if it were on the main transcript. - proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId) - if (leafUuid === previousLeafUuid) { - proveAppendOrder(nodes) - return { leafUuid, relation: 'same' } - } - const visited = new Set() - let cursor: string | null = leafUuid - for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { - if (visited.has(cursor)) { - throw transcriptError('cycle in parentUuid ancestry') + if (!previousLeafUuid) { + proveMainLineAncestry(nodes, leafUuid, input.providerSessionId) + // A branch proof is based on an append-only snapshot. A child that appears + // before its claimed parent is not a post-snapshot descendant observation; + // accepting that graph would turn reordered/torn rows into durable ancestry. + proveAppendOrder(nodes) + return { leafUuid, relation: 'initial' } @@ -205,4 +195,3 @@ - visited.add(cursor) - const node = nodes.get(cursor) - if (!node || node.sessionId !== input.providerSessionId) { - throw transcriptError(`missing ancestor ${cursor}`) + const previous = nodes.get(previousLeafUuid) + if (!previous) { + throw new ClaudeTranscriptPreviousCursorMissingError() @@ -210,2 +199,2 @@ - if (node.disallowedLeaf) { - throw transcriptError(`ancestor ${cursor} is not on the main transcript`) + if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) { + throw transcriptError('previous cursor is not on the main transcript') @@ -213,2 +202,6 @@ - cursor = node.parentUuid - if (cursor === previousLeafUuid) { + // The latest marker can be equal to, or descend from, a sampled cursor. In + // either case prove the sampled cursor's own ancestry before accepting it; + // otherwise a cursor that descended through a parent-tool-use sidechain + // could be persisted and resumed as if it were on the main transcript. + proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId) + if (leafUuid === previousLeafUuid) { @@ -216,1 +209,1 @@ - return { leafUuid, relation: 'descendant' } + return { leafUuid, relation: 'same' } @@ -217,0 +211,24 @@ + const visited = new Set() + let cursor: string | null = leafUuid + for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { + if (visited.has(cursor)) { + throw transcriptError('cycle in parentUuid ancestry') + } + visited.add(cursor) + const node = nodes.get(cursor) + if (!node || node.sessionId !== input.providerSessionId) { + throw transcriptError(`missing ancestor ${cursor}`) + } + if (node.disallowedLeaf) { + throw transcriptError(`ancestor ${cursor} is not on the main transcript`) + } + cursor = node.parentUuid + if (cursor === previousLeafUuid) { + proveAppendOrder(nodes) + return { leafUuid, relation: 'descendant' } + } + } + if (cursor !== null) { + throw transcriptError('ancestry exceeds the bounded proof limit') + } + throw transcriptError('latest marker is on a sibling branch') @@ -219,2 +236,9 @@ - if (cursor !== null) { - throw transcriptError('ancestry exceeds the bounded proof limit') +} + +export function proveClaudeTranscriptBranchFromJsonl( + input: BranchProofInput & { contents: string } +): ClaudeTranscriptBranchProof { + const proof = createBranchProof(input) + const lines = input.contents.split('\n') + for (const [index, line] of lines.entries()) { + proof.add(line, index, index < lines.length - 1) @@ -222,1 +246,1 @@ - throw transcriptError('latest marker is on a sibling branch') + return proof.finish() @@ -225,12 +249,44 @@ -export async function proveClaudeTranscriptBranch(input: { - transcriptPath: string - providerSessionId: string - previousLeafUuid: string | null - intentionalRewindUuid?: string -}): Promise { - return proveClaudeTranscriptBranchFromJsonl({ - contents: await readFile(input.transcriptPath, 'utf8'), - providerSessionId: input.providerSessionId, - previousLeafUuid: input.previousLeafUuid, - intentionalRewindUuid: input.intentionalRewindUuid - }) +export async function proveClaudeTranscriptBranch( + input: BranchProofInput & { transcriptPath: string } +): Promise { + const handle = await open(input.transcriptPath, 'r') + try { + let size = (await handle.stat()).size + let refreshed = false + while (true) { + const proof = createBranchProof(input) + let index = 0 + try { + if (size > 0) { + const stream = handle.createReadStream({ start: 0, end: size - 1, autoClose: false }) + for await (const record of splitTranscriptStreamLines(stream)) { + proof.add(record.line, index++, record.terminated) + } + } + return proof.finish() + } catch (error) { + if ( + !(error instanceof ClaudeTranscriptMarkerMissingError) && + !(error instanceof ClaudeTranscriptPreviousCursorMissingError) && + !(error instanceof ClaudeTranscriptTailIncompleteError) + ) { + throw error + } + if (refreshed) { + throw new ClaudeTranscriptTailIncompleteError() + } + const nextSize = await handle.stat().then( + (current) => current.size, + () => size + ) + if (nextSize <= size) { + throw error + } + // Finish an already-appended repair without making the caller retry. + size = nextSize + refreshed = true + } + } + } finally { + await handle.close() + }